setState 到底是同步的,还是异步的|原理篇
30 秒速记
- 核心判断:状态更新是否立刻可见取决于调度边界和批处理,不应简单记成“同步”或“异步”
- 原理主线:围绕 「从一道面试题说起」、「异步的动机和原理——批量更新的艺术」、「“同步现象”背后的故事:从源码角度看 s」 建立输入、状态变化与输出之间的因果关系
- 文章范围:解析了 React setState 的同步与异步机制,通过代码示例和原理分析,帮助开发者理解 setState 在不同场景下的执行时机、批量更新机制及其对组件状态管理的影响,是面试和实际开发中常见高频问题的权威解答。
- 边界与代价:
React 18自动批处理范围扩大,读取旧值、连续更新和强制同步提交需要分别讨论 - 工程落地:依赖前值时使用函数式更新;只有确有 DOM 读取时才考虑同步提交,并评估阻塞代价
setState 不能简单归类为同步或异步,状态何时可见取决于 React 的批处理边界。 在受 React 管控的事件中,多次更新会先进入队列并合并,避免每次调用都触发一次完整的重渲染,所以紧接着读取往往还是旧值。对象形式连续写入同一字段时,读取的又是同一个旧状态,最终不等于累加多次。原文中 setTimeout 后立即可见属于 React 15 的历史实现现象,不能直接套到其他版本。
这篇文章不要按 API 清单来背。先用上面的 Mind Map 建立全局结构,再通过交互 DEMO 观察正常路径和边界路径如何改变状态;阅读正文时重点核对每一步的输入、负责执行的参与者、产生的中间状态以及最终可观察结果。遇到版本敏感结论,要把“历史实现”“当前行为”和“工程兼容策略”分开说明;遇到性能或架构取舍,则用实际指标、失败现象和验证手段支撑判断。
版本校准: 原理文章中的代码代表特定实现与写作时间。应用到当前项目时,应先确认浏览器、框架或工具的主版本,再区分稳定的规范语义、可变化的内部实现和项目自身约束。
# 从一道面试题说起
这是一道变体繁多的面试题,在 BAT 等一线大厂的面试中考察频率非常高。首先题目会给出一个这样的 App 组件,在它的内部会有如下代码所示的几个不同的 setState 操作:
import React from "react";
import "./styles.css";
export default class App extends React.Component{
state = {
count: 0
}
increment = () => {
console.log('increment setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
console.log('increment setState后的count', this.state.count)
}
triple = () => {
console.log('triple setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
console.log('triple setState后的count', this.state.count)
}
reduce = () => {
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
},0);
}
render(){
return <div>
<button onClick={this.increment}>点我增加</button>
<button onClick={this.triple}>点我增加三倍</button>
<button onClick={this.reduce}>点我减少</button>
</div>
}
}
接着我把组件挂载到 DOM 上:
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
rootElement
);
此时浏览器里渲染出来的是如下图所示的三个按钮:

此时有个问题,若从左到右依次点击每个按钮,控制台的输出会是什么样的?读到这里,建议你先暂停 1 分钟在脑子里跑一下代码,看看和下图实际运行出来的结果是否有出入。

如果你是一个熟手 React 开发,那么 increment 这个方法的输出结果想必难不倒你——正如许许多多的 React 入门教学所声称的那样,“setState 是一个异步的方法”,这意味着当我们执行完 setState 后,state 本身并不会立刻发生改变。 因此紧跟在 setState 后面输出的 state 值,仍然会维持在它的初始状态(0)。在同步代码执行完毕后的某个“神奇时刻”,state 才会“恰恰好”地增加到 1。
但这个“神奇时刻”到底何时发生,所谓的“恰恰好”又如何界定呢?如果你对这个问题搞不太清楚,那么 triple 方法的输出对你来说就会有一定的迷惑性——setState 一次不好使, setState 三次也没用,state 到底是在哪个环节发生了变化呢?
带着这样的困惑,你决定先抛开一切去看看 reduce 方法里是什么光景,结果更令人大跌眼镜,reduce 方法里的 setState 竟然是同步更新的!这…到底是我们初学 React 时拿到了错误的基础教程,还是电脑坏了?
要想理解眼前发生的这魔幻的一切,我们还得从 setState 的工作机制里去找线索。
# 异步的动机和原理——批量更新的艺术
我们首先要认知的一个问题:在 setState 调用之后,都发生了哪些事情?你可能会更倾向于站在生命周期的角度去思考这个问题,得出一个如下图所示的结论:

从图上我们可以看出,一个完整的更新流程,涉及了包括 re-render(重渲染) 在内的多个步骤。re-render 本身涉及对 DOM 的操作,它会带来较大的性能开销。假如说“一次 setState 就触发一个完整的更新流程”这个结论成立,那么每一次 setState 的调用都会触发一次 re-render,我们的视图很可能没刷新几次就卡死了。这个过程如我们下面代码中的箭头流程图所示:
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
事实上,这正是 setState 异步的一个重要的动机——避免频繁的 re-render。
在实际的 React 运行时中,setState 异步的实现方式有点类似于 Vue 的 $nextTick 和浏览器里的 Event-Loop:每来一个 setState,就把它塞进一个队列里“攒起来”。等时机成熟,再把“攒起来”的 state 结果做合并,最后只针对最新的 state 值走一次更新流程。这个过程,叫作“批量更新”,批量更新的过程正如下面代码中的箭头流程图所示:
this.setState({
count: this.state.count + 1 ===> 入队,[count+1的任务]
});
this.setState({
count: this.state.count + 1 ===> 入队,[count+1的任务,count+1的任务]
});
this.setState({
count: this.state.count + 1 ===> 入队, [count+1的任务,count+1的任务, count+1的任务]
});
↓
合并 state,[count+1的任务]
↓
执行 count+1的任务
值得注意的是,只要我们的同步代码还在执行,“攒起来”这个动作就不会停止。(注:这里之所以多次
+1最终只有一次生效,是因为在同一个方法中多次 setState 的合并动作不是单纯地将更新累加。比如这里对于相同属性的设置,React 只会为其保留最后一次的更新)。因此就算我们在 React 中写了这样一个 100 次的 setState 循环:
test = () => {
console.log('循环100次 setState前的count', this.state.count)
for(let i=0;i<100;i++) {
this.setState({
count: this.state.count + 1
})
}
console.log('循环100次 setState后的count', this.state.count)
}
也只是会增加 state 任务入队的次数,并不会带来频繁的 re-render。当 100 次调用结束后,仅仅是
state的任务队列内容发生了变化,state本身并不会立刻改变:

# “同步现象”背后的故事:从源码角度看 setState 工作流
读到这里,相信你对异步这回事多少有些眉目了。接下来我们就要重点理解刚刚代码里最诡异的一部分——setState 的同步现象:
reduce = () => {
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
},0);
}
从题目上看,setState 似乎是在 setTimeout 函数的“保护”之下,才有了同步这一“特异功能”。事实也的确如此,假如我们把 setTimeout 摘掉,setState 前后的 console 表现将会与 increment 方法中无异:
